Skip to content

fix(DEV-1756): bound generated identifiers to the dialect's length limit - #289

Merged
ZmeiGorynych merged 12 commits into
mainfrom
egor/dev-1756-autogenerated-projection-aliases-exceed-postgres-63-byte
Aug 11, 2026
Merged

fix(DEV-1756): bound generated identifiers to the dialect's length limit#289
ZmeiGorynych merged 12 commits into
mainfrom
egor/dev-1756-autogenerated-projection-aliases-exceed-postgres-63-byte

Conversation

@ZmeiGorynych

@ZmeiGorynych ZmeiGorynych commented Aug 6, 2026

Copy link
Copy Markdown
Member

Closes DEV-1756 (projection-alias surface). Join-path table aliases are explicitly out of scope — see below.

The bug

Postgres caps identifiers at 63 bytes and truncates past it silently (a NOTICE, never an error). SLayer's alias convention <root_model>.<join.path>.<column> crosses that on a 3-hop join:

SandboxInvoiceV2.SandboxSubscription.SandboxCustomer.SandboxConsumer.name    73 B
SandboxInvoiceV2.SandboxSubscription.SandboxCustomer.SandboxConsumer.email   74 B
                                                     ^ both truncate here (63 B)

Two failure modes, and the quiet one is worse:

Symptom
Two colliding siblings + the DEV-1444 outer wrap AmbiguousColumnError: column reference "SandboxInvoiceV2.SandboxSubscription.SandboxCustomer.SandboxCon" is ambiguous — the reported error
Two colliding siblings, no wrap Columns collapse into one result key
A single over-limit alias Query succeeds; the row comes back keyed by the truncated name, the engine looks up the canonical one, the column silently disappears

The repro is executed against a real Postgres in tests/integration/test_dev1756_identifier_length_pg.py, not just asserted on emitted bytes — the whole point is that byte-level tests pass while the server misbehaves.

The fix

Each dialect declares a conservative SqlDialect.max_identifier_bytes; an over-limit identifier is fitted at emission to <head>_<hash8>_<tail> (new slayer/sql/dialects/_identifier_fit.py):

orders.customers.regions.districts.neighbourhood_name
  ->  orders.customers.region_4f2a91c7_ricts.neighbourhood_name
  • Pure function of the full original name. The digest covers the whole alias, not the truncated head, so the read side rebuilds the emitted->canonical map by simply re-running it — no map threaded through generation. decode_result_keys restores the canonical dotted alias, so data / columns / attributes are unchanged on every backend; only response.sql shows the fitted form, because that is what ran.
  • Only when over the limit. Under-limit output is byte-identical, pinned against goldens captured before the change rather than by an idempotence check that a broken pass would also satisfy.
  • Both ends preserved. The reported colliding pair differs only in its final segment, so a head-only truncation would make them indistinguishable in dry_run output.

Write side

Exact-match replacement driven by the query's own alias set — never a length regex over arbitrary SQL, so a long quoted-looking span inside a string literal (WHERE note LIKE '%\"...\"%') cannot be corrupted. The set is all_projection_aliases, deliberately unfiltered: hidden ORDER-BY hoists and _inner_* / _ft* / _ts* entries are projected in the inner SELECT and truncate identically, so a filtered list would leave their references pointing at an unfitted name. Substitution is two-phase as defence-in-depth (today's key set and value set are provably disjoint, so it cannot cascade — that invariant is pinned by its own test).

BigQuery / T-SQL

Their existing dot-mangle regex runs after the length pass. An under-limit alias makes the length pass a genuine no-op, so their output is byte-identical to today; an over-limit one arrives still-dotted and is mangled by the same regex, so there is no double-encoding. The budget is sized against encode_alias, since mangling lengthens the identifier after fitting.

Scope

Three output-name surfaces: projection aliases, CTE names (allocated through a per-statement collision-checked SQLGenerator._cte_name, budget counted including the _cm_ / cp_value_12_ prefix), and _query_as_model virtual-model shorts.

Join-path table aliases (customers__regions) are deferred to DEV-1743, which already owns the __ path-alias allocator and plans exactly this collision check. Their failure mode (silent wrong joins) is worse, but fixing it means decoupling EnrichedDimension.model_name from the emitted qualifier across ~8 sites that split it back on __ — a different, riskier change that would swamp review of the reported fix. A detailed handoff is on DEV-1743.

Bonus: a pre-existing bug in the same code path

_query_as_model emitted its short alias bare, so Postgres case-folded it while the outer stage referenced it quoted (DEV-1645 quotes mixed-case Column.sql leaves). Any query-backed model with a mixed-case join path was unqueryable:

inner:  ... AS SandboxConsumer__name                    -- folds to sandboxconsumer__name
outer:  stage1."SandboxConsumer__name"                  -- case-sensitive
-> UndefinedColumnError: column stage1.SandboxConsumer__name does not exist

Reproduced on a live server and fixed by always dialect-quoting the short, which also takes these names out of the case-folding namespace entirely. This is why two TestMultiStageMeasureRename assertions move from AS rev to AS \"rev\".

Collisions

Two SLayer-generated names colliding after fitting raises IdentifierCollisionError rather than emitting ambiguous SQL. The check covers identity entries too — an already-short alias equal to another's fitted form is a duplicate that no hash width can prevent. Validated per namespace: projection aliases, CTE names, and virtual-model shorts (the last case-insensitively).

Verification

  • 7450 passed non-integration; 464 passed integration (Postgres / DuckDB / SQLite / …)
  • 9 new live-Postgres tests; 7 of them failed for the right reasons before the fix, including the exact reported AmbiguousColumnError
  • Neutralizing fit_identifier fails 70 of the 209 new tests — they exercise the behaviour, not just the imports
  • ruff check slayer/ tests/ clean

Two Codex review rounds (plan, then tests) folded in. One finding rejected with reasoning: rewriting aliases at AST-construction time would touch ~20 emission sites and still miss the string-concatenated CTE-assembly paths.

Docs

docs/database-support.md gains an identifier-length section with the per-dialect table and the stated approximations (bytes not chars; MySQL's identifier limit rather than its column-alias limit; Oracle 12.2+). docs/concepts/queries.md notes that result keys are always canonical. DECISIONS.md entry appended. No new pages, so zensical.toml nav is unchanged.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added dialect-aware handling for long SQL aliases, CTE names, and query-backed model aliases.
    • Preserved canonical result keys when database-specific aliases are shortened or rewritten.
    • Added clear errors when generated identifiers conflict.
    • Improved alias quoting for reserved words, case-sensitive names, and unsafe identifier shapes.
  • Documentation

    • Documented database identifier limits, deterministic alias shortening, collision behavior, and canonical response keys.

Postgres caps identifiers at 63 bytes and truncates past it SILENTLY (a
NOTICE, never an error). SLayer's alias convention
`<root_model>.<join.path>.<column>` crosses that on a 3-hop join, so two
sibling aliases collapse onto one output name:

  SandboxInvoiceV2.SandboxSubscription.SandboxCustomer.SandboxConsumer.name   73 B
  SandboxInvoiceV2.SandboxSubscription.SandboxCustomer.SandboxConsumer.email  74 B

Under the DEV-1444 outer wrap that raises AmbiguousColumnError; with no
sibling to collide with the query succeeds and the column silently
disappears, which is the worse case.

Every dialect now declares a conservative `max_identifier_bytes` budget,
and an over-limit identifier is fitted at emission to
`<head>_<hash8>_<tail>` (new `slayer/sql/dialects/_identifier_fit.py`).
Fitting is a pure function of the full original name, so the read side
rebuilds the emitted->canonical map by re-running it — nothing is threaded
through generation — and `decode_result_keys` restores the canonical dotted
alias. Consumers never see the shortened form; only `response.sql` does,
because that is the SQL that ran.

Shortened only when over the limit, so under-limit output stays
byte-identical (pinned by pre-change goldens, not merely by idempotence).
Both ends of the alias survive because the reported colliding pair differs
only in its final segment.

The write pass is an exact-match replacement over the query's own alias set
(`all_projection_aliases`, unfiltered — hidden ORDER-BY hoists are projected
in the inner SELECT and truncate identically), never a length regex over
arbitrary SQL, so a long quoted-looking span inside a string literal cannot
be corrupted. Substitution is two-phase as defence-in-depth. BigQuery/T-SQL
run their existing dot-mangle regex after the length pass: an under-limit
alias makes that pass a no-op so their output is unchanged, and an
over-limit one arrives still-dotted and is mangled by the same regex — no
double-encoding — with the budget sized against `encode_alias`.

Covers the three output-name surfaces: projection aliases, CTE names
(allocated through a per-statement collision-checked `SQLGenerator._cte_name`),
and `_query_as_model` virtual-model shorts. Join-path TABLE aliases are
deferred to DEV-1743, which already owns the `__` path-alias allocator.
Collisions raise `IdentifierCollisionError` rather than emitting ambiguous
SQL; the check covers identity entries too, since an already-short alias
equal to another's fitted form is a duplicate no hash width can prevent.

Also fixes a pre-existing bug in the same code path, reproduced on a live
server: `_query_as_model` emitted its short alias bare, so Postgres
case-folded it while the outer stage referenced it quoted, making any
query-backed model with a mixed-case join path unqueryable
(UndefinedColumnError). It is now always dialect-quoted, which additionally
takes these names out of the case-folding namespace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@linear

linear Bot commented Aug 6, 2026

Copy link
Copy Markdown

DEV-1756

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4ee33c4b-3046-445d-b5c5-c967cbff2838

📥 Commits

Reviewing files that changed from the base of the PR and between 229c0f3 and f3b70be.

📒 Files selected for processing (1)
  • docs/database-support.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/database-support.md

📝 Walkthrough

Walkthrough

The change adds dialect-aware fitting for projection aliases, CTE names, and query-backed model aliases. It detects collisions, rewrites generated SQL, restores canonical result keys, and documents database-specific identifier limits.

Changes

Identifier fitting and alias preservation

Layer / File(s) Summary
Fitting contracts and dialect budgets
slayer/core/errors.py, slayer/sql/dialects/_identifier_fit.py, slayer/sql/dialects/..., tests/dialects/test_identifier_fit.py
Identifiers use UTF-8-safe shortening with an 8-character SHA-256 marker. Dialects define bounded or unbounded identifier budgets. Collisions raise IdentifierCollisionError.
Dialect alias emission and decoding
slayer/sql/dialects/base.py, slayer/sql/dialects/bigquery.py, slayer/sql/dialects/tsql.py, tests/dialects/...
Dialect hooks fit and quote aliases, rewrite emitted SQL, and restore canonical result keys after dialect-specific mangling.
CTE allocation and projection rewriting
slayer/sql/generator.py, tests/test_dev1756_identifier_length.py, tests/test_sql_generator.py
CTE names use per-statement fitting and collision detection. SQL rewriting receives all projection aliases.
Virtual-model alias integration
slayer/engine/enriched.py, slayer/engine/query_engine.py, tests/test_dev1756_identifier_length.py, tests/integration/..., tests/test_query_backed_models.py
Virtual-model columns, wrapper aliases, cross-model names, and lineage breadcrumbs use fitted identifiers. Query-backed aliases use dialect-aware quoting and collision checks.
Behavior documentation
docs/database-support.md, docs/concepts/queries.md, DECISIONS.md
Documentation describes identifier budgets, fitting, collision detection, canonical response keys, and join-path alias limits.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SqlGenerator
  participant SqlDialect
  participant PostgreSQL
  participant QueryEngine
  SqlGenerator->>SqlDialect: Rewrite SQL with projection aliases
  SqlDialect-->>SqlGenerator: Emit fitted aliases
  SqlGenerator->>PostgreSQL: Execute generated SQL
  PostgreSQL-->>QueryEngine: Return rows with emitted keys
  QueryEngine->>SqlDialect: Decode result keys with aliases
  SqlDialect-->>QueryEngine: Restore canonical dotted keys
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.09% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: limiting generated identifiers to each SQL dialect's length limit.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch egor/dev-1756-autogenerated-projection-aliases-exceed-postgres-63-byte

Comment @coderabbitai help to get the list of available commands.

ZmeiGorynych and others added 5 commits August 6, 2026 15:54
Review findings from Codex on PR #289, all in the same family: a collision
check that could not observe the collision it was meant to catch.

1. `_query_as_model` keyed its short-name allocation by the short itself
   (`prior != short`), so two DISTINCT aliases landing on the IDENTICAL
   short compared equal and passed. Key by the owning inner alias instead,
   which catches exact duplicates as well as the case-only and fitted-form
   variants it already caught.

   Enrichment has a related guard, but it does not cover this: its
   `_occupied_shorts` map is populated from dimensions and checked only
   against MEASURES, so dimension-vs-dimension slips through, and its local
   flattening helper applies no length fitting, so two shorts that differ
   before fitting and collide after it pass there too. A root column named
   `SandboxSubscription__SandboxCustomer__SandboxConsumer__name` alongside
   the deep dimension that flattens to the same string reaches the emission
   boundary undetected — column names permit `__`, so this is reachable.

2/3. BigQuery and T-SQL `decode_result_keys` pre-decoded row keys in a dict
   comprehension and only then passed the result to `_rekey_row`. Two keys
   normalizing to the same name collapsed in that intermediate dict, with
   one value silently dropped, before the duplicate check ever ran — the
   exact failure mode this PR exists to prevent. `_rekey_row` now takes an
   optional `fallback` decoder so mapping lookup, bijection fallback, and
   duplicate detection all happen in one pass.

Also fixes 12 SonarQube findings in the new tests (3 of them bug-class
`python:S5863`, which failed the quality gate on `new_reliability_rating`):
tautological `f(x) == f(x)` assertions replaced with pinned expected values,
composite `assert a and b` split, and `get_dialect(...)` hoisted out of
`pytest.raises` blocks so only one call inside can throw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to the previous commit: moving `decode_alias_map(aliases)` into
the list comprehension rebuilt it for every result row, turning decoding
from O(aliases + rows x columns) into O(rows x aliases + rows x columns).
The map is a pure function of `aliases`, so hoist it back out of the loop.

Codex review finding on PR #289.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-exceed-postgres-63-byte

Brings in DEV-1741 (ingestion sees views, survives unmodellable names) and
the v8 storage migration.

One conflict, in DECISIONS.md: both branches appended a final entry to the
append-only log. Kept both in chronological order — DEV-1741 (2026-08-05)
then DEV-1756 (2026-08-06).

Verified on the merged tree: 7601 unit tests pass (up from 7455 — main's
146 new tests came along clean), 464 integration tests pass, ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three review findings on the identifier-length work, plus two doc fixes.

1. `_query_as_model` quoted its short alias UNCONDITIONALLY, which fixed the
   Postgres mixed-case bug by creating the mirror-image bug on upper-folding
   backends: the wrapper defined a case-sensitive `"status"` while the
   downstream `Column(sql="status")` reference stayed bare (only
   `_quote_mixed_case_identifiers` quotes it, and only when it contains an
   uppercase letter) and resolved as `STATUS` on Snowflake/Oracle. The
   contract is AGREEMENT between the two sides, not quoting, so `_short_sql`
   now mirrors the reference-side policy: quote iff mixed-case or reserved.
   Three tests bracket it — the agreement test fails against both the pre-PR
   version and the always-quote version.

2. Caller-supplied virtual-model shorts bypassed the length fitting.
   `m.name` / `t.name` / `e.name` and a user-declared cross-model `name` went
   into `column_map` verbatim, so two over-limit names sharing a 63-byte
   prefix were emitted as `AS "<64+ bytes>"` and truncated onto one column by
   Postgres — invisible to the `short_owner` check, which compares Python
   strings. New `_fit_short` covers all four sites plus the DEV-1449
   `agg_shorts` breadcrumb, so it cannot drift from `column_map`.

3. `substitute_quoted`'s "cannot reach into a string literal" was an
   overclaim. Being keyed to an exact alias set bounds the exposure to a
   literal holding the exact quoted spelling of one of the same query's
   over-limit aliases; it does not eliminate it. Docstrings corrected — no
   code change, since the BigQuery/T-SQL dot-mangle regexes already run over
   this SQL with strictly wider exposure.

4. The `docs/database-support.md` worked example was fabricated: a 53-byte
   input, under the limit, paired with output `fit_identifier` never
   produces. Replaced with real 68 -> 63 output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dcoded set

Follow-up review found the previous predicate
(`short.lower() in SLAYER_RESERVED_KEYWORDS or any(c.isupper() ...)`) wrong on
both of its axes for some dialects:

* It misses words reserved only NATIVELY. `install_reserved_keywords` unions
  SLayer's set INTO each sqlglot generator's own, so only the generator knows
  the full set. On MySQL `index` / `int` / `rows` (and `rows` on BigQuery) are
  quoted by the downstream reference while the wrapper emitted them bare — and
  a bare `AS index` is a syntax error there, not merely a mismatch.
* It ignores shape. `Column.name` forbids only `.` and `:`, so a short can be
  `1abc` or `foo bar`, which needs quoting on form alone regardless of case or
  reserved-ness.

Rather than grow the predicate, `_short_sql` now runs the short through the
same two mechanisms the downstream `Column(sql=short)` reference goes through:
`SQLGenerator._maybe_quote_ident` (DEV-1645's uppercase rule) followed by
`Identifier.sql(dialect=...)`, which lets sqlglot apply the target dialect's
reserved-word and identifier-safety rules. The two spellings are then equal by
construction rather than by a rule that has to be kept in sync.

Pinned by a 12-dialect x 7-name test asserting the wrapper spelling and the
reference spelling are byte-identical; it fails on 4 dialect/name pairs against
the previous predicate. The remaining wrapper/reference disagreements are all
names `_parse` cannot read as a column at all (`select`, `foo-bar`, `1abc`) —
pre-existing, and unreferenceable however the wrapper spells them.

Also splits a composite assertion flagged by Sonar (python:S9073).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (5)
slayer/sql/dialects/bigquery.py (1)

144-146: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use keyword arguments in new multi-parameter calls.

  • slayer/sql/dialects/bigquery.py#L144-L146: call fit_identifier(name=name, ...).
  • slayer/sql/dialects/bigquery.py#L172-L175: call rewrite_emitted_sql(sql=sql, ...).
  • slayer/sql/dialects/bigquery.py#L194-L197: call _rekey_row(row=row, mapping=mapping, ...).
  • slayer/sql/generator.py#L285-L287: call fit_identifier(name=prefix + sanitized, ...).
  • slayer/sql/generator.py#L434-L436: call _cte_name_from_alias(prefix=prefix, alias=alias, ...).
  • slayer/sql/generator.py#L681-L683: call rewrite_emitted_sql(sql=sql, ...).
  • slayer/sql/generator.py#L805-L805: call _cte_name(prefix="_cm_", alias=cm.alias).
  • slayer/sql/generator.py#L890-L898: call _cte_name(prefix=..., alias=measure.alias).
  • slayer/sql/generator.py#L1824-L1826: call _cte_name(prefix=..., alias=transform.alias).

As per coding guidelines, “Use keyword arguments for functions with more than one parameter.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@slayer/sql/dialects/bigquery.py` around lines 144 - 146, Update all listed
multi-parameter calls to use keyword arguments: in
slayer/sql/dialects/bigquery.py lines 144-146, 172-175, and 194-197, name the
fit_identifier, rewrite_emitted_sql, and _rekey_row arguments explicitly; in
slayer/sql/generator.py lines 285-287, 434-436, 681-683, 805, 890-898, and
1824-1826, likewise use explicit keywords for fit_identifier,
_cte_name_from_alias, rewrite_emitted_sql, and _cte_name arguments. Preserve
each call’s current values and behavior.

Source: Coding guidelines

docs/database-support.md (1)

54-57: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add a language to the fenced code block.

markdownlint reports MD040 for this block. Use text, since the content is illustrative output rather than runnable code.

📝 Proposed fix
-```
+```text
 orders.customers.regions.districts.neighbourhoods.neighbourhood_name   (68 bytes)
   ->  orders.customers.regions.di_e6932600_urhoods.neighbourhood_name  (63 bytes)
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @docs/database-support.md around lines 54 - 57, Add the text language
identifier to the fenced code block containing the illustrative schema output,
preserving its contents unchanged.


</details>

<!-- cr-comment:v1:1660843745c1a86d2596db7d -->

_Source: Linters/SAST tools_

</blockquote></details>
<details>
<summary>tests/test_dev1756_identifier_length.py (1)</summary><blockquote>

`669-711`: _📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _💤 Low value_

**Hoist the repeated function-local imports.**

`_cte_name_from_alias` is imported separately in seven test bodies, and `re` is imported again at line 706 although the module does not import it at top level. The coding guidelines require imports at the top of the file. Move `from slayer.sql.generator import SQLGenerator, _cte_name_from_alias` and `import re` into the module header at lines 30-43.

As per coding guidelines: "Keep imports at the top of files."

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @tests/test_dev1756_identifier_length.py around lines 669 - 711, Move the
repeated local imports to the module header: add _cte_name_from_alias alongside
SQLGenerator and add re with the other top-level imports. Remove the
function-body imports from the affected CTE-name tests while leaving their
assertions unchanged.


</details>

<!-- cr-comment:v1:be031695ea49e5565552f97a -->

_Source: Coding guidelines_

</blockquote></details>
<details>
<summary>tests/integration/test_dev1756_identifier_length_pg.py (1)</summary><blockquote>

`144-147`: _🩺 Stability & Availability_ | _🔵 Trivial_ | _⚡ Quick win_

**Dispose the engine after each test.**

`chain_env` creates a new `SlayerQueryEngine` for each of the nine tests, but the tests call `execute(...)` directly. Use an async-yield fixture and call `await engine.aclose()` in `finally` to release each async SQLAlchemy connection pool.

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @tests/integration/test_dev1756_identifier_length_pg.py around lines 144 -
147, Update the chain_env fixture to be async and yield the SlayerQueryEngine
instance, ensuring await engine.aclose() runs in a finally block after each
test; preserve the existing _chain_storage setup and direct execute(...) usage.


</details>

<!-- cr-comment:v1:9ee1bf06e0e801f6d36e7a2d -->

</blockquote></details>
<details>
<summary>slayer/engine/query_engine.py (1)</summary><blockquote>

`3232-3259`: _📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _⚡ Quick win_

**Use a keyword for `fit_identifier`'s `name` argument.**

`fit_identifier` has more than one parameter. Use a keyword for `name`.

<details>
<summary>Proposed fix</summary>

```diff
 return fit_identifier(
-    name, limit=get_dialect(dialect).max_identifier_bytes,
+    name=name, limit=get_dialect(dialect).max_identifier_bytes,
 )

As per coding guidelines, **/*.py: “Use keyword arguments for functions with more than one parameter.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@slayer/engine/query_engine.py` around lines 3232 - 3259, Update the
fit_identifier call in _fit_short to pass the name argument by keyword, while
preserving the existing dialect limit keyword and behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/concepts/queries.md`:
- Around line 134-141: Update the identifier-length sentence in the result-key
description to avoid claiming that every engine imposes a cap; state that
aliases are rewritten only when the target database requires it, while
preserving the existing BigQuery and SQL Server behavior and the distinction
that only sql exposes the rewritten form.

In `@slayer/engine/query_engine.py`:
- Around line 1391-1402: Update get_column_types to decode fitted aliases in
raw_types before the metadata comparison loop, using the same
get_dialect(...).decode_result_keys call and
all_projection_aliases(prepared.enriched) mapping used in _run_and_build. Keep
the existing type-building loop unchanged so canonical EnrichedMeasure.alias
values are matched correctly.
- Around line 3323-3353: Update the virtual-model column construction to store
the rendered _short_sql(short) value in Column.sql, ensuring it matches the
emitted alias. Adjust _is_trivial_base to recognize dialect-specific quoted
self-identities, including double quotes, backticks, and brackets, so unsafe
aliases remain base columns and do not trigger false ColumnCycleError failures.

In `@slayer/sql/generator.py`:
- Around line 1824-1826: Update _generate_with_computed to allocate the shifted
and sjoin CTE names through _cte_name(), then reuse those allocated names
consistently in both CTE definitions and all references. Remove direct
shifted_{t.name} and sjoin_{t.name} construction so long transform aliases are
bounded like the consecutive-period names.
- Around line 438-444: Update the CTE allocation logic around _cte_names and
setdefault to key unquoted names using the dialect-aware normalization required
for case-insensitive identifier resolution, while retaining the original emitted
name for collision reporting. Ensure differently cased names collide on
PostgreSQL without changing IdentifierCollisionError’s first, second, or emitted
values.

In `@tests/test_dev1756_identifier_length.py`:
- Around line 661-667: Update test_cte_allocator_resets_per_statement to
exercise reset through generate() rather than clearing _cte_names directly:
create two enriched statements with different CTE owners, configure them so
their emitted names collide, and generate both using the same SQLGenerator
instance. Add assertions verifying each statement receives the expected
allocation and that per-statement state is reset; do not rely on generating the
same statement twice because _cte_name() is idempotent for one owner.

---

Nitpick comments:
In `@docs/database-support.md`:
- Around line 54-57: Add the `text` language identifier to the fenced code block
containing the illustrative schema output, preserving its contents unchanged.

In `@slayer/engine/query_engine.py`:
- Around line 3232-3259: Update the fit_identifier call in _fit_short to pass
the name argument by keyword, while preserving the existing dialect limit
keyword and behavior.

In `@slayer/sql/dialects/bigquery.py`:
- Around line 144-146: Update all listed multi-parameter calls to use keyword
arguments: in slayer/sql/dialects/bigquery.py lines 144-146, 172-175, and
194-197, name the fit_identifier, rewrite_emitted_sql, and _rekey_row arguments
explicitly; in slayer/sql/generator.py lines 285-287, 434-436, 681-683, 805,
890-898, and 1824-1826, likewise use explicit keywords for fit_identifier,
_cte_name_from_alias, rewrite_emitted_sql, and _cte_name arguments. Preserve
each call’s current values and behavior.

In `@tests/integration/test_dev1756_identifier_length_pg.py`:
- Around line 144-147: Update the chain_env fixture to be async and yield the
SlayerQueryEngine instance, ensuring await engine.aclose() runs in a finally
block after each test; preserve the existing _chain_storage setup and direct
execute(...) usage.

In `@tests/test_dev1756_identifier_length.py`:
- Around line 669-711: Move the repeated local imports to the module header: add
_cte_name_from_alias alongside SQLGenerator and add re with the other top-level
imports. Remove the function-body imports from the affected CTE-name tests while
leaving their assertions unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c75cc9a7-f1e6-46fa-9ecd-d0569d619b7e

📥 Commits

Reviewing files that changed from the base of the PR and between 995412d and f573c35.

📒 Files selected for processing (24)
  • DECISIONS.md
  • docs/concepts/queries.md
  • docs/database-support.md
  • slayer/core/errors.py
  • slayer/engine/enriched.py
  • slayer/engine/query_engine.py
  • slayer/sql/dialects/_identifier_fit.py
  • slayer/sql/dialects/_tier2.py
  • slayer/sql/dialects/base.py
  • slayer/sql/dialects/bigquery.py
  • slayer/sql/dialects/clickhouse.py
  • slayer/sql/dialects/duckdb.py
  • slayer/sql/dialects/mysql.py
  • slayer/sql/dialects/postgres.py
  • slayer/sql/dialects/snowflake.py
  • slayer/sql/dialects/sqlite.py
  • slayer/sql/dialects/tsql.py
  • slayer/sql/generator.py
  • tests/dialects/test_bigquery.py
  • tests/dialects/test_identifier_fit.py
  • tests/integration/test_dev1756_identifier_length_pg.py
  • tests/test_dev1756_identifier_length.py
  • tests/test_query_backed_models.py
  • tests/test_sql_generator.py

Comment thread docs/concepts/queries.md
Comment thread slayer/engine/query_engine.py Outdated
Comment thread slayer/engine/query_engine.py Outdated
Comment thread slayer/sql/generator.py Outdated
Comment thread slayer/sql/generator.py Outdated
Comment thread tests/test_dev1756_identifier_length.py Outdated
ZmeiGorynych and others added 4 commits August 7, 2026 11:02
CodeRabbit round on f573c35. Three real gaps, each with a test that fails
without its fix:

* `get_column_types` probed with generated SQL — which now carries FITTED
  aliases — but looked the results up by the canonical `EnrichedMeasure.alias`.
  An over-limit measure silently vanished from the type map (verified: the map
  came back empty). Decoded with the same hook and alias set `_run_and_build`
  uses.

* `shifted_<name>` / `sjoin_<name>` self-join CTE names were f-string-built
  from a USER-supplied transform name, bypassing both the length fitting and
  the collision check. A 78-char transform name produced 86-byte CTE
  identifiers on Postgres while the projection alias beside them was correctly
  fitted. Routed through `_cte_name()`, so definition and every reference share
  one allocation.

* The `_cte_name` allocator keyed on the literal emitted name, but CTE names
  are emitted UNQUOTED — so `_wm_Foo` and `_wm_foo` are one identifier on
  Postgres and both were accepted, silently pointing a reference at the wrong
  CTE. Keyed casefolded. Unlike the projection-alias namespace there is no
  quoted variant to exempt, so here casefolding is exact rather than merely
  conservative.

Also:
* `test_cte_allocator_resets_per_statement` asserted nothing and cleared
  `_cte_names` by hand. It now seeds an owner the statement does not use and
  drives the reset through `generate()` — regenerating the same statement
  proves nothing, since `_cte_name` is idempotent for one owner.
* Corrected "every engine caps identifier length" in docs/concepts/queries.md;
  four dialects are unbounded, as the table two files over already says.
* Keyword arguments at the multi-parameter call sites this PR added, per the
  repo convention.

Not taken: storing `_short_sql(short)` in `Column.sql`. Unsafe-shaped shorts
are unreachable — `ColumnRef.name`, `ModelMeasure.name` and `Aggregation.name`
all enforce the full identifier pattern, so a query can neither reference nor
name one. Pre-quoting `Column.sql` would instead double-quote it relative to
the downstream reference and desync the pair f573c35 just aligned. Rationale
posted on the thread.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… over S3776

The decode added in 2d887d5 came with an `if raw_types:` guard, which pushed
the function's cognitive complexity from 15 to 16 (Sonar python:S3776). The
guard was never needed: `decode_result_keys` returns its input untouched when
the alias map is empty, and re-keying an empty dict yields an empty dict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both remaining multi-parameter calls this PR added were still passing `sql`
positionally. Completes the convention sweep from 2d887d5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trim the identifier-fitting comments/docstrings that read like design
essays down to the essential why. Verified comment/docstring-only: code
AST is byte-identical, full non-integration suite still green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/dialects/test_identifier_fit.py (1)

99-105: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Run the child interpreter through Poetry.

Line 101 invokes sys.executable directly. Replace it with poetry run python so this subprocess uses the required project command path.

Proposed fix
-                sys.executable, "-c",
+                "poetry", "run", "python", "-c",

As per coding guidelines, “Use poetry run for all Python commands.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/dialects/test_identifier_fit.py` around lines 99 - 105, Update the
subprocess command in the identifier-fit test around subprocess.run to invoke
Python through Poetry, replacing the direct sys.executable argument with the
required poetry run python command while preserving the existing script and
options.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@slayer/engine/query_engine.py`:
- Around line 1391-1395: Use keyword arguments for all supported parameters in
the three calls: in slayer/engine/query_engine.py lines 1391-1395, pass rows by
keyword to decode_result_keys; in lines 2049-2053, pass the type-probe row list
by keyword to decode_result_keys; and in lines 3231-3239, pass name by keyword
to fit_identifier.

---

Outside diff comments:
In `@tests/dialects/test_identifier_fit.py`:
- Around line 99-105: Update the subprocess command in the identifier-fit test
around subprocess.run to invoke Python through Poetry, replacing the direct
sys.executable argument with the required poetry run python command while
preserving the existing script and options.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 22213994-f91b-4f9d-9b00-dd4a615db7a9

📥 Commits

Reviewing files that changed from the base of the PR and between f573c35 and ef1b57c.

📒 Files selected for processing (20)
  • docs/concepts/queries.md
  • slayer/core/errors.py
  • slayer/engine/enriched.py
  • slayer/engine/query_engine.py
  • slayer/sql/dialects/_identifier_fit.py
  • slayer/sql/dialects/_tier2.py
  • slayer/sql/dialects/base.py
  • slayer/sql/dialects/bigquery.py
  • slayer/sql/dialects/clickhouse.py
  • slayer/sql/dialects/duckdb.py
  • slayer/sql/dialects/mysql.py
  • slayer/sql/dialects/postgres.py
  • slayer/sql/dialects/snowflake.py
  • slayer/sql/dialects/sqlite.py
  • slayer/sql/dialects/tsql.py
  • slayer/sql/generator.py
  • tests/dialects/test_identifier_fit.py
  • tests/integration/test_dev1756_identifier_length_pg.py
  • tests/test_dev1756_identifier_length.py
  • tests/test_query_backed_models.py
💤 Files with no reviewable changes (1)
  • slayer/sql/dialects/snowflake.py
🚧 Files skipped from review as they are similar to previous changes (16)
  • slayer/sql/dialects/clickhouse.py
  • docs/concepts/queries.md
  • slayer/engine/enriched.py
  • tests/test_query_backed_models.py
  • slayer/sql/dialects/postgres.py
  • slayer/sql/dialects/_identifier_fit.py
  • slayer/sql/dialects/tsql.py
  • slayer/sql/dialects/duckdb.py
  • slayer/sql/dialects/_tier2.py
  • slayer/core/errors.py
  • slayer/sql/dialects/mysql.py
  • slayer/sql/dialects/bigquery.py
  • slayer/sql/dialects/sqlite.py
  • slayer/sql/dialects/base.py
  • tests/test_dev1756_identifier_length.py
  • tests/integration/test_dev1756_identifier_length_pg.py

Comment on lines +1391 to +1395
# Reverse dialect alias fitting/mangling so response keys are canonical.
# Pass the unfiltered alias set: a row may carry a hidden ORDER-BY hoist.
rows = get_dialect(prepared.dialect).decode_result_keys(
rows, aliases=all_projection_aliases(prepared.enriched),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use keyword arguments for the decode and fitting calls.

Use keyword arguments for each supported parameter in these calls.

  • slayer/engine/query_engine.py#L1391-L1395: pass rows to decode_result_keys by keyword.
  • slayer/engine/query_engine.py#L2049-L2053: pass the type-probe row list to decode_result_keys by keyword.
  • slayer/engine/query_engine.py#L3231-L3239: pass name to fit_identifier by keyword.
Proposed fix
-            rows, aliases=all_projection_aliases(prepared.enriched),
+            rows=rows, aliases=all_projection_aliases(prepared.enriched),

-            [raw_types], aliases=all_projection_aliases(enriched),
+            rows=[raw_types], aliases=all_projection_aliases(enriched),

-                name, limit=get_dialect(dialect).max_identifier_bytes,
+                name=name, limit=get_dialect(dialect).max_identifier_bytes,

As per coding guidelines, “Use keyword arguments for functions with more than one parameter.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Reverse dialect alias fitting/mangling so response keys are canonical.
# Pass the unfiltered alias set: a row may carry a hidden ORDER-BY hoist.
rows = get_dialect(prepared.dialect).decode_result_keys(
rows, aliases=all_projection_aliases(prepared.enriched),
)
# Reverse dialect alias fitting/mangling so response keys are canonical.
# Pass the unfiltered alias set: a row may carry a hidden ORDER-BY hoist.
rows = get_dialect(prepared.dialect).decode_result_keys(
rows=rows, aliases=all_projection_aliases(prepared.enriched),
)
raw_types = get_dialect(dialect).decode_result_keys(
rows=[raw_types], aliases=all_projection_aliases(enriched),
)[0]
def _fit_short(name: str) -> str:
"""Fit a virtual-model short to the dialect's budget; identity under the limit.
Every short is both an output alias and a downstream ``Column.name``,
and caller-supplied names can be arbitrarily long.
"""
return fit_identifier(
name=name, limit=get_dialect(dialect).max_identifier_bytes,
)
📍 Affects 1 file
  • slayer/engine/query_engine.py#L1391-L1395 (this comment)
  • slayer/engine/query_engine.py#L2049-L2053
  • slayer/engine/query_engine.py#L3231-L3239
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@slayer/engine/query_engine.py` around lines 1391 - 1395, Use keyword
arguments for all supported parameters in the three calls: in
slayer/engine/query_engine.py lines 1391-1395, pass rows by keyword to
decode_result_keys; in lines 2049-2053, pass the type-probe row list by keyword
to decode_result_keys; and in lines 3231-3239, pass name by keyword to
fit_identifier.

Source: Coding guidelines

…erated-projection-aliases-exceed-postgres-63-byte

# Conflicts:
#	DECISIONS.md
#	slayer/sql/dialects/bigquery.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
slayer/sql/dialects/base.py (1)

534-567: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Detect collisions on the final emitted alias.

alias_rewrite_map() uses fit_alias() as its collision key. BigQuery applies dot mangling later in emit_alias(). Therefore, a.b and a___b pass this check but both emit as a___b.

The generated query can contain duplicate result aliases. decode_alias_map() then overwrites one canonical mapping. This can return data under the wrong result key.

Use emit_alias(alias) for allocation ownership. Keep fit_alias(alias) only as the canonical-to-write-replacement value. Validate the same allocation before building the decode map.

Proposed fix
-        if self.max_identifier_bytes is None:
-            return {}
         allocation: dict[str, str] = {}
-        owner: dict[str, str] = {}
+        emitted_owner: dict[str, str] = {}
         for alias in aliases:
             if alias in allocation:
                 continue
             fitted = self.fit_alias(alias)
-            prior = owner.get(fitted)
+            emitted = self.emit_alias(alias)
+            prior = emitted_owner.get(emitted)
             if prior is not None and prior != alias:
                 raise IdentifierCollisionError(
-                    first=prior, second=alias, emitted=fitted,
+                    first=prior, second=alias, emitted=emitted,
                     dialect=self.sqlglot_name, limit=self.max_identifier_bytes,
                     namespace="projection alias",
                 )
-            owner[fitted] = alias
+            emitted_owner[emitted] = alias
             allocation[alias] = fitted
     def decode_alias_map(self, aliases: Sequence[str]) -> dict[str, str]:
+        self.alias_rewrite_map(aliases)
         out: dict[str, str] = {}

Also applies to: 600-631

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@slayer/sql/dialects/base.py` around lines 534 - 567, Update
alias_rewrite_map() to use emit_alias(alias) as the ownership and collision key
while retaining fit_alias(alias) as the canonical write replacement. Add
equivalent collision validation in decode_alias_map() before populating its
inverse mapping, raising IdentifierCollisionError for distinct canonical aliases
with the same emitted alias so mappings cannot be silently overwritten.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@slayer/sql/dialects/base.py`:
- Around line 534-567: Update alias_rewrite_map() to use emit_alias(alias) as
the ownership and collision key while retaining fit_alias(alias) as the
canonical write replacement. Add equivalent collision validation in
decode_alias_map() before populating its inverse mapping, raising
IdentifierCollisionError for distinct canonical aliases with the same emitted
alias so mappings cannot be silently overwritten.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7c20481c-fc77-4692-82e4-a6de00397874

📥 Commits

Reviewing files that changed from the base of the PR and between ef1b57c and 229c0f3.

📒 Files selected for processing (6)
  • DECISIONS.md
  • slayer/engine/query_engine.py
  • slayer/sql/dialects/base.py
  • slayer/sql/dialects/bigquery.py
  • tests/dialects/test_bigquery.py
  • tests/test_sql_generator.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • slayer/engine/query_engine.py
  • tests/test_sql_generator.py

Reduce the database-support.md section from ~60 lines to a 7-line note:
some DBs cap identifier length (Postgres 63 bytes, silent), SLayer trims
over-limit aliases, output column names are unaffected, only the
introspectable SQL shows the trimmed form.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@ZmeiGorynych
ZmeiGorynych merged commit 7b4a0b8 into main Aug 11, 2026
12 checks passed
ZmeiGorynych added a commit that referenced this pull request Aug 17, 2026
…length fitting)

Ports DEV-1756 to the DEV-1450 structural pipeline for the projection-alias
AND CTE-name surfaces (main's enrichment-based wiring does not apply here).

Projection aliases:
- Kept main's design intact: pure fit_identifier/substitute_quoted module,
  the aliases-taking SqlDialect.rewrite_emitted_sql/decode_result_keys +
  fit_alias/emit_alias/alias_rewrite_map/decode_alias_map/_rekey_row, and the
  per-dialect max_identifier_bytes budgets.
- Write and read sides share ONE plan-derived source of canonical projection
  keys: new response_meta.projection_result_keys(root_planned). The write side
  passes it into generate_planned_stages(projection_aliases=...) (NOT parsed
  off the SQL — sqlglot cannot parse a pre-mangle BigQuery dotted alias); the
  read side threads the same set into build_response_metadata, _run_data_query
  (via _Prepared.expected_columns) and the get_column_types probe.

CTE names:
- naming.cte_name_from_alias now length-fits prefix+sanitized via
  fit_identifier before AliasAllocator.allocate_cte, and raises
  IdentifierCollisionError if the allocator's _2 suffix pushes a fitted name
  back over the limit. dialect+limit threaded from the _cm_/_wm_/ranked and the
  time-shift shifted_/sjoin_ CTE call sites. Closes the deep-cross-model
  Postgres truncation-collision gap; limit=None is byte-identical to before.

Conflict resolutions: git rm enriched.py; take-HEAD for generator.py /
query_engine.py (old-SQLGenerator hunks), re-adding the fitting hooks; union
imports in base/bigquery/tsql (branch naming + fit_identifier); DECISIONS.md
date-interleave.

Tests: test_identifier_fit.py + the projection/CTE scenarios of
test_dev1756_identifier_length.py rewritten to drive the real engine and pass.
Virtual-model short fitting (no _query_as_model on this branch) stays skipped
as a documented follow-up. Two no-churn goldens assert intent (no _<hash>_
marker) since the branch emits CAST(SUM(...)) where main emitted SUM(...).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant